margin 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- margin/__init__.py +0 -0
- margin/batch.py +41 -0
- margin/claude.py +89 -0
- margin/cli.py +294 -0
- margin/config.py +188 -0
- margin/fold.py +750 -0
- margin/images.py +66 -0
- margin/inbox.py +335 -0
- margin/outline.py +282 -0
- margin/patches.py +243 -0
- margin/pinch_check.py +363 -0
- margin/skills/margin/SKILL.md +95 -0
- margin/store.py +186 -0
- margin/sync.py +666 -0
- margin-0.2.1.dist-info/METADATA +113 -0
- margin-0.2.1.dist-info/RECORD +20 -0
- margin-0.2.1.dist-info/WHEEL +5 -0
- margin-0.2.1.dist-info/entry_points.txt +2 -0
- margin-0.2.1.dist-info/licenses/LICENSE +21 -0
- margin-0.2.1.dist-info/top_level.txt +1 -0
margin/__init__.py
ADDED
|
File without changes
|
margin/batch.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""When to tell the agent about comments: a settle window, so a burst becomes one message.
|
|
2
|
+
|
|
3
|
+
A route (one repo's chat) has a batch open from its first undelivered comment. The batch is ready
|
|
4
|
+
when the person has been quiet for `quiet` seconds, or `cap` seconds after its first comment so a
|
|
5
|
+
long reading session still gets answers, or at once when a comment mentions the agent.
|
|
6
|
+
|
|
7
|
+
Times are this machine's receive times (seconds since the epoch), never the server's timestamps,
|
|
8
|
+
so a clock or timezone difference between the two cannot hold a batch back or release it early.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ready(rows, now: float, quiet: float, cap: float) -> dict[str, list]:
|
|
16
|
+
by_route: dict[str, list] = defaultdict(list)
|
|
17
|
+
for r in rows:
|
|
18
|
+
by_route[r["route"]].append(r)
|
|
19
|
+
out = {}
|
|
20
|
+
for route, rs in by_route.items():
|
|
21
|
+
first = min(r["received_at"] for r in rs)
|
|
22
|
+
last = max(r["received_at"] for r in rs)
|
|
23
|
+
if any(r["mentions_agent"] for r in rs) or now - last >= quiet or now - first >= cap:
|
|
24
|
+
out[route] = sorted(rs, key=lambda r: r["received_at"])
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def due_in(rows, now: float, quiet: float, cap: float) -> float | None:
|
|
29
|
+
"""Seconds until the next batch becomes ready, for a status line. None when nothing waits."""
|
|
30
|
+
if not rows:
|
|
31
|
+
return None
|
|
32
|
+
best = None
|
|
33
|
+
by_route: dict[str, list] = defaultdict(list)
|
|
34
|
+
for r in rows:
|
|
35
|
+
by_route[r["route"]].append(r)
|
|
36
|
+
for rs in by_route.values():
|
|
37
|
+
first = min(r["received_at"] for r in rs)
|
|
38
|
+
last = max(r["received_at"] for r in rs)
|
|
39
|
+
t = max(0.0, min(last + quiet, first + cap) - now)
|
|
40
|
+
best = t if best is None else min(best, t)
|
|
41
|
+
return best
|
margin/claude.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`margin setup-claude`: wire margin and Outline into Claude Code, idempotently.
|
|
2
|
+
|
|
3
|
+
Three things, each safe to run again:
|
|
4
|
+
|
|
5
|
+
1. the margin skill, linked into ~/.claude/skills/margin from this package, so it updates with it;
|
|
6
|
+
2. Outline's own MCP server registered in ~/.claude.json, with the agent's key supplied at connect
|
|
7
|
+
time by a small headers script that runs `api_key_command`, so the key is never written into
|
|
8
|
+
a config file;
|
|
9
|
+
3. Outline's destructive MCP tools denied in ~/.claude/settings.json. An agent that deletes or
|
|
10
|
+
moves a document, or changes a collection, would break the folder structure margin mirrors.
|
|
11
|
+
|
|
12
|
+
Both JSON files are backed up once per run before they are rewritten, atomically.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shlex
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .config import Config
|
|
25
|
+
|
|
26
|
+
DENY = ["delete_document", "delete_collection", "update_collection", "create_collection",
|
|
27
|
+
"move_document"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rewrite(path: Path, change) -> bool:
|
|
31
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
32
|
+
before = json.dumps(data, sort_keys=True)
|
|
33
|
+
change(data)
|
|
34
|
+
if json.dumps(data, sort_keys=True) == before:
|
|
35
|
+
return False
|
|
36
|
+
if path.exists():
|
|
37
|
+
shutil.copy2(path, path.with_name(path.name + f".bak-margin-{time.strftime('%Y%m%d%H%M%S')}"))
|
|
38
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent))
|
|
39
|
+
with os.fdopen(fd, "w") as f:
|
|
40
|
+
json.dump(data, f, indent=2)
|
|
41
|
+
os.replace(tmp, path)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def setup(cfg: Config, home: Path | None = None, name: str = "outline") -> list[str]:
|
|
46
|
+
home = home or Path.home()
|
|
47
|
+
done = []
|
|
48
|
+
|
|
49
|
+
# 1. the skill
|
|
50
|
+
skill_src = Path(__file__).parent / "skills" / "margin"
|
|
51
|
+
skills = home / ".claude" / "skills"
|
|
52
|
+
skills.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
link = skills / "margin"
|
|
54
|
+
if link.is_symlink() or not link.exists():
|
|
55
|
+
if not (link.is_symlink() and Path(os.readlink(link)) == skill_src):
|
|
56
|
+
if link.is_symlink():
|
|
57
|
+
link.unlink()
|
|
58
|
+
link.symlink_to(skill_src)
|
|
59
|
+
done.append(f"skill linked: {link} -> {skill_src}")
|
|
60
|
+
else:
|
|
61
|
+
done.append(f"skill left alone: {link} exists and is not a link")
|
|
62
|
+
|
|
63
|
+
# 2. the MCP server and its headers script
|
|
64
|
+
helper = home / ".config" / "margin" / "outline-mcp-headers.sh"
|
|
65
|
+
helper.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
body = ("#!/bin/sh\n# written by margin setup-claude: the agent's Outline key, read at connect time\n"
|
|
67
|
+
f"k=$({shlex.join(cfg.api_key_command)}) || exit 1\n"
|
|
68
|
+
"printf '{\"Authorization\": \"Bearer %s\"}\\n' \"$k\"\n")
|
|
69
|
+
if not helper.exists() or helper.read_text() != body:
|
|
70
|
+
helper.write_text(body)
|
|
71
|
+
done.append(f"headers script written: {helper}")
|
|
72
|
+
helper.chmod(0o700)
|
|
73
|
+
url = cfg.mcp_url or cfg.url.rstrip("/") + "/mcp"
|
|
74
|
+
|
|
75
|
+
def add_server(d):
|
|
76
|
+
d.setdefault("mcpServers", {})[name] = {"type": "http", "url": url,
|
|
77
|
+
"headersHelper": str(helper)}
|
|
78
|
+
if _rewrite(home / ".claude.json", add_server):
|
|
79
|
+
done.append(f"MCP server '{name}' registered at {url}")
|
|
80
|
+
|
|
81
|
+
# 3. deny the destructive tools
|
|
82
|
+
def deny(d):
|
|
83
|
+
lst = d.setdefault("permissions", {}).setdefault("deny", [])
|
|
84
|
+
for t in DENY:
|
|
85
|
+
if f"mcp__{name}__{t}" not in lst:
|
|
86
|
+
lst.append(f"mcp__{name}__{t}")
|
|
87
|
+
if _rewrite(home / ".claude" / "settings.json", deny):
|
|
88
|
+
done.append("destructive Outline tools denied: " + ", ".join(DENY))
|
|
89
|
+
return done or ["already set up"]
|
margin/cli.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""margin: publish repo markdown to Outline and bring comments back to an agent.
|
|
2
|
+
|
|
3
|
+
margin sync [REPO...] [--full] publish changed files (all repos when none named)
|
|
4
|
+
margin serve webhook receiver + catch-up poll + batching + periodic sync
|
|
5
|
+
margin webhook create Outline's webhook subscription if it is missing
|
|
6
|
+
margin status documents that need attention, and comments waiting
|
|
7
|
+
margin reply COMMENT_ID TEXT answer in the comment's thread, as the configured user
|
|
8
|
+
margin resolve COMMENT_ID mark the thread resolved
|
|
9
|
+
margin thread COMMENT_ID print a whole thread with its quoted text
|
|
10
|
+
margin link FILE... publish these files now and print a Markdown link to each
|
|
11
|
+
margin setup-claude install the skill, Outline's MCP and its tool denials
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from . import batch
|
|
22
|
+
from .config import DEFAULT_PATH, load, locate
|
|
23
|
+
from .inbox import EVENTS, Inbox, serve
|
|
24
|
+
from .outline import Outline, comment_text, pin_host
|
|
25
|
+
from .store import Store
|
|
26
|
+
from .sync import Syncer
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connect(cfg):
|
|
30
|
+
if cfg.pin_ip_command:
|
|
31
|
+
from urllib.parse import urlparse
|
|
32
|
+
pin_host(urlparse(cfg.url).hostname, cfg.run(cfg.pin_ip_command).split()[0])
|
|
33
|
+
return Outline(cfg.url, cfg.api_key())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_webhook(cfg, o: Outline) -> str:
|
|
37
|
+
"""Webhook subscriptions need an admin. The agent's own key is a member's, on purpose, so the
|
|
38
|
+
admin key is used for this one call only and never kept on the client."""
|
|
39
|
+
if not (cfg.webhook_url and cfg.secret_command):
|
|
40
|
+
return "no webhook configured; the catch-up poll is the only path"
|
|
41
|
+
if not cfg.admin_key_command:
|
|
42
|
+
return "no admin_key_command; create the webhook once as an admin (margin webhook)"
|
|
43
|
+
admin = Outline(cfg.url, cfg.run(cfg.admin_key_command))
|
|
44
|
+
w = admin.ensure_webhook("margin", cfg.webhook_url, cfg.secret(), EVENTS)
|
|
45
|
+
return f"webhook {w['id']} -> {w['url']} ({', '.join(w.get('events') or [])})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def thread_of(o: Outline, comment_id: str) -> tuple[dict, list[dict]]:
|
|
49
|
+
c = o.comment(comment_id)
|
|
50
|
+
root = o.comment(c["parentCommentId"]) if c.get("parentCommentId") else c
|
|
51
|
+
rows = [x for x in o.comments(document_id=root["documentId"])
|
|
52
|
+
if x["id"] == root["id"] or x.get("parentCommentId") == root["id"]]
|
|
53
|
+
return root, sorted(rows, key=lambda x: x["createdAt"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
ap = argparse.ArgumentParser(prog="margin", description=__doc__,
|
|
58
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
59
|
+
ap.add_argument("--config", default=str(DEFAULT_PATH))
|
|
60
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
61
|
+
p = sub.add_parser("sync")
|
|
62
|
+
p.add_argument("repos", nargs="*")
|
|
63
|
+
p.add_argument("--full", action="store_true")
|
|
64
|
+
sub.add_parser("serve")
|
|
65
|
+
sub.add_parser("webhook")
|
|
66
|
+
sub.add_parser("status")
|
|
67
|
+
p = sub.add_parser("reply")
|
|
68
|
+
p.add_argument("comment_id")
|
|
69
|
+
p.add_argument("text")
|
|
70
|
+
p = sub.add_parser("resolve")
|
|
71
|
+
p.add_argument("comment_id")
|
|
72
|
+
p = sub.add_parser("thread")
|
|
73
|
+
p.add_argument("comment_id")
|
|
74
|
+
p = sub.add_parser("link")
|
|
75
|
+
p.add_argument("files", nargs="+")
|
|
76
|
+
p = sub.add_parser("accept-file", help="let the file win a conflict no person made")
|
|
77
|
+
p.add_argument("files", nargs="+")
|
|
78
|
+
p = sub.add_parser("accept-merged",
|
|
79
|
+
help="publish a file that now carries a person's Outline edit")
|
|
80
|
+
p.add_argument("files", nargs="+")
|
|
81
|
+
p.add_argument("--undo", action="append", default=[],
|
|
82
|
+
help="text from a part of their edit the person agreed to undo (repeatable)")
|
|
83
|
+
sub.add_parser("setup-claude")
|
|
84
|
+
sub.add_parser("anchors", help="record where each older comment's passage sits in its document")
|
|
85
|
+
p = sub.add_parser("pinch-check", help="pinch a fold page on an Android phone over adb and judge each pinch")
|
|
86
|
+
p.add_argument("page", help="the fold page (margin fold --html)")
|
|
87
|
+
p.add_argument("--serial", required=True, help="the phone, as adb names it")
|
|
88
|
+
p.add_argument("--package", default="com.brave.browser", help="the phone's Chromium browser")
|
|
89
|
+
p.add_argument("--out", help="the folder for the table, the readings and the recording")
|
|
90
|
+
p.add_argument("--wait-unlock", type=float, default=0.0,
|
|
91
|
+
help="seconds to wait for the phone to be awake and unlocked (never unlocked for you)")
|
|
92
|
+
p = sub.add_parser("fold", help="fold a markdown file into five levels of detail, each checked")
|
|
93
|
+
p.add_argument("file")
|
|
94
|
+
p.add_argument("--out", help="write the fold as JSON here")
|
|
95
|
+
p.add_argument("--html", help="write a page to read the fold here")
|
|
96
|
+
p.add_argument("--model", default="sonnet")
|
|
97
|
+
p.add_argument("--judge-model", default="opus")
|
|
98
|
+
a = ap.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
if a.cmd == "pinch-check":
|
|
101
|
+
from pathlib import Path as P
|
|
102
|
+
from .pinch_check import run as pinch_run
|
|
103
|
+
return pinch_run(P(a.page).expanduser().resolve(), a.serial, package=a.package,
|
|
104
|
+
out=P(a.out).expanduser() if a.out else None, wait_unlock=a.wait_unlock)
|
|
105
|
+
|
|
106
|
+
if a.cmd == "fold":
|
|
107
|
+
from functools import partial
|
|
108
|
+
from pathlib import Path as P
|
|
109
|
+
from .fold import ask_claude, ask_judge, fold_file
|
|
110
|
+
cache = P(os.environ.get("MARGIN_FOLD_CACHE", "~/.cache/margin/folds")).expanduser()
|
|
111
|
+
f = fold_file(P(a.file), out=P(a.out) if a.out else None, html_out=P(a.html) if a.html else None,
|
|
112
|
+
cache=cache, ask=partial(ask_claude, model=a.model),
|
|
113
|
+
judge=partial(ask_judge, model=a.judge_model))
|
|
114
|
+
c = f["counts"]
|
|
115
|
+
print(f"folded {a.file}: {c['units']} paragraphs, {c['claude']} by Claude, "
|
|
116
|
+
f"{c['author']} kept in the author's words, {c['calls']} calls")
|
|
117
|
+
for s in f["sections"]:
|
|
118
|
+
for u in s["units"]:
|
|
119
|
+
if u["source"] == "author":
|
|
120
|
+
print(f" author's words: {u['id']}: {'; '.join(u['refused'])}")
|
|
121
|
+
if s["section"]["source"] == "author":
|
|
122
|
+
print(f" section in the author's words: {s['id']}: {'; '.join(s['section']['refused'])}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
from pathlib import Path
|
|
126
|
+
cfg = load(Path(a.config).expanduser())
|
|
127
|
+
if a.cmd == "setup-claude":
|
|
128
|
+
from .claude import setup
|
|
129
|
+
for line in setup(cfg):
|
|
130
|
+
print(line)
|
|
131
|
+
return 0
|
|
132
|
+
o = connect(cfg)
|
|
133
|
+
store = Store(cfg.state)
|
|
134
|
+
|
|
135
|
+
def sync_all(names=(), full=False, quiet=True):
|
|
136
|
+
out = []
|
|
137
|
+
for repo in cfg.repos:
|
|
138
|
+
if names and repo.name not in names:
|
|
139
|
+
continue
|
|
140
|
+
for r in Syncer(o, store, repo, full=full).run():
|
|
141
|
+
if not quiet or r.action != "unchanged":
|
|
142
|
+
out.append((repo.name, r))
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
if a.cmd == "sync":
|
|
146
|
+
unknown = set(a.repos) - {r.name for r in cfg.repos}
|
|
147
|
+
if unknown:
|
|
148
|
+
print(f"unknown repo(s): {', '.join(sorted(unknown))}", file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
res = sync_all(a.repos, a.full)
|
|
151
|
+
for repo, r in res:
|
|
152
|
+
print(f"{r.action:18} {repo}/{r.path}" + (f" ({r.detail})" if r.detail else ""))
|
|
153
|
+
if not res:
|
|
154
|
+
print("nothing changed")
|
|
155
|
+
return 1 if any(r.action in ("error", "cannot-patch") for _, r in res) else 0
|
|
156
|
+
|
|
157
|
+
if a.cmd == "serve":
|
|
158
|
+
# Outline may still be starting (a 502 from its proxy): wait for it instead of exiting
|
|
159
|
+
o.wait_ready(log=lambda m: print(m, file=sys.stderr, flush=True))
|
|
160
|
+
inbox = Inbox(cfg, o, store)
|
|
161
|
+
try:
|
|
162
|
+
print(ensure_webhook(cfg, o), flush=True)
|
|
163
|
+
except Exception as e: # the poll still delivers without the webhook
|
|
164
|
+
print(f"webhook not ensured: {e}", file=sys.stderr, flush=True)
|
|
165
|
+
|
|
166
|
+
def sync_pass():
|
|
167
|
+
for repo, r in sync_all():
|
|
168
|
+
print(f"sync: {r.action} {repo}/{r.path}" + (f" ({r.detail})" if r.detail else ""),
|
|
169
|
+
flush=True)
|
|
170
|
+
# a changed text moves its passages, and a rewritten one loses its mark
|
|
171
|
+
row = store.doc(repo, r.path) if r.action in ("patched", "outline-edited") else None
|
|
172
|
+
if row:
|
|
173
|
+
inbox.place(row["doc_id"])
|
|
174
|
+
|
|
175
|
+
serve(inbox, sync_pass)
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
if a.cmd == "webhook":
|
|
179
|
+
print(ensure_webhook(cfg, o))
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
if a.cmd == "status":
|
|
183
|
+
bad = [d for d in store.docs() if d["status"] != "ok"]
|
|
184
|
+
for d in bad:
|
|
185
|
+
print(f"{d['status']:18} {d['repo']}/{d['path']} {d['detail'] or ''}")
|
|
186
|
+
pend = store.pending()
|
|
187
|
+
wait = batch.due_in(pend, time.time(), cfg.quiet_seconds, cfg.cap_seconds)
|
|
188
|
+
print(f"{len(store.docs())} documents, {len(bad)} need attention, {len(pend)} comment(s) "
|
|
189
|
+
"waiting" + (f", next batch in {int(wait)} s" if wait is not None else ""))
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
if a.cmd == "anchors":
|
|
193
|
+
inbox = Inbox(cfg, o, store)
|
|
194
|
+
done = gone = 0
|
|
195
|
+
for doc_id in dict.fromkeys(r["document_id"] for r in store.without_anchor_before()):
|
|
196
|
+
p, g = inbox.place(doc_id)
|
|
197
|
+
done, gone = done + p, gone + g
|
|
198
|
+
print(f"{done} passage(s) placed, {gone} with no mark left in their document")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
if a.cmd == "reply":
|
|
202
|
+
c = o.comment(a.comment_id)
|
|
203
|
+
root = c.get("parentCommentId") or c["id"]
|
|
204
|
+
r = o.reply(c["documentId"], root, a.text)
|
|
205
|
+
print(f"replied in thread {root} as {o.me()['name']}: {r['id']}")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
if a.cmd == "resolve":
|
|
209
|
+
c = o.comment(a.comment_id)
|
|
210
|
+
o.resolve(c.get("parentCommentId") or c["id"])
|
|
211
|
+
print("resolved")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
if a.cmd == "link":
|
|
215
|
+
from .outline import doc_url
|
|
216
|
+
by_repo: dict = {}
|
|
217
|
+
bad = 0
|
|
218
|
+
for f in a.files:
|
|
219
|
+
hit = locate(cfg, Path(f))
|
|
220
|
+
if not hit:
|
|
221
|
+
print(f"not published: {f} is in no configured repo (nor in a git worktree of "
|
|
222
|
+
"one). Put it in a repo margin publishes, or add its repo to the config.",
|
|
223
|
+
file=sys.stderr)
|
|
224
|
+
bad = 1
|
|
225
|
+
continue
|
|
226
|
+
by_repo.setdefault((hit[0].name, hit[2]), (hit[0], []))[1].append(hit[1])
|
|
227
|
+
author = cfg.author() # the comments on what you link come back to you
|
|
228
|
+
for (name, root), (repo, rels) in by_repo.items():
|
|
229
|
+
got = {rel: (d, st, det)
|
|
230
|
+
for rel, d, st, det in Syncer(o, store, repo).publish_status(rels, root)}
|
|
231
|
+
for rel, (d, _, _) in got.items():
|
|
232
|
+
if d and author:
|
|
233
|
+
store.put(f"author:{d['id']}", author)
|
|
234
|
+
for rel in rels:
|
|
235
|
+
d, status, detail = got.get(rel, (None, "", ""))
|
|
236
|
+
if d and status == "ok":
|
|
237
|
+
print(f"[{d['title']}]({doc_url(cfg.url, d)})")
|
|
238
|
+
elif d:
|
|
239
|
+
# The document exists but does not say what the file says. A link printed here
|
|
240
|
+
# read as "published" and a chat reported a section that never reached Outline.
|
|
241
|
+
# ⭐ which kind it is, so a chat neither stops to ask about an edit nobody made
|
|
242
|
+
# nor forces its file over one a person did make (2026-10-16)
|
|
243
|
+
why = (" Nobody edited it: margin could not patch it into the file's text. "
|
|
244
|
+
"`margin accept-file` publishes the file when only margin has edited it."
|
|
245
|
+
if status in ("cannot-patch", "drift") else
|
|
246
|
+
" Someone may have edited it in Outline: `margin accept-file` publishes the "
|
|
247
|
+
"file only if nobody did, and otherwise names who did.")
|
|
248
|
+
print(f"not published: {name}/{rel} is {status}"
|
|
249
|
+
+ (f" ({detail})" if detail else "")
|
|
250
|
+
+ f". The document still has the older text: [{d['title']}]"
|
|
251
|
+
f"({doc_url(cfg.url, d)}).{why}", file=sys.stderr)
|
|
252
|
+
bad = 1
|
|
253
|
+
else:
|
|
254
|
+
print(f"not published: {name}/{rel} is excluded by the repo's include or "
|
|
255
|
+
"exclude patterns, or is not markdown", file=sys.stderr)
|
|
256
|
+
bad = 1
|
|
257
|
+
return bad
|
|
258
|
+
|
|
259
|
+
if a.cmd in ("accept-file", "accept-merged"):
|
|
260
|
+
from .outline import doc_url
|
|
261
|
+
bad = 0
|
|
262
|
+
for f in a.files:
|
|
263
|
+
hit = locate(cfg, Path(f))
|
|
264
|
+
if not hit:
|
|
265
|
+
print(f"refused: {f} is in no configured repo", file=sys.stderr)
|
|
266
|
+
bad = 1
|
|
267
|
+
continue
|
|
268
|
+
s = Syncer(o, store, hit[0])
|
|
269
|
+
if a.cmd == "accept-file":
|
|
270
|
+
r = s.accept_file(hit[1], hit[2])
|
|
271
|
+
else:
|
|
272
|
+
r = s.accept_merged(hit[1], hit[2], tuple(a.undo))
|
|
273
|
+
row = store.doc(hit[0].name, r.path)
|
|
274
|
+
if r.action in ("patched", "unchanged") and row and row["status"] == "ok":
|
|
275
|
+
d = o.document(row["doc_id"])
|
|
276
|
+
print(f"accepted the file: [{d['title']}]({doc_url(cfg.url, d)})")
|
|
277
|
+
else:
|
|
278
|
+
print(f"refused: {hit[0].name}/{hit[1]} ({r.action}"
|
|
279
|
+
+ (f": {r.detail}" if r.detail else "") + ")", file=sys.stderr)
|
|
280
|
+
bad = 1
|
|
281
|
+
return bad
|
|
282
|
+
|
|
283
|
+
if a.cmd == "thread":
|
|
284
|
+
root, rows = thread_of(o, a.comment_id)
|
|
285
|
+
if root.get("anchorText"):
|
|
286
|
+
print(f'on "{root["anchorText"]}"')
|
|
287
|
+
for x in rows:
|
|
288
|
+
print(f"[{x['id']}] {x['createdBy']['name']}: {comment_text(x.get('data'))}")
|
|
289
|
+
return 0
|
|
290
|
+
return 2
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
raise SystemExit(main())
|
margin/config.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""margin's configuration: one TOML file, by default ~/.config/margin/config.toml.
|
|
2
|
+
|
|
3
|
+
[outline]
|
|
4
|
+
url = "https://docs.example.com"
|
|
5
|
+
api_key_command = ["security", "find-generic-password", "-s", "outline-api-key", "-w"]
|
|
6
|
+
pin_ip_command = ["tailscale", "ip", "-4"] # optional: resolve the url's host to this IP
|
|
7
|
+
admin_key_command = [...] # optional: an admin's key, used ONLY to create the webhook
|
|
8
|
+
mcp_url = "http://127.0.0.1:3100/mcp" # optional: Outline's MCP, default <url>/mcp
|
|
9
|
+
|
|
10
|
+
[intake]
|
|
11
|
+
listen = "127.0.0.1:8771"
|
|
12
|
+
webhook_url = "http://host.docker.internal:8771/outline" # what Outline posts to
|
|
13
|
+
secret_command = ["security", "find-generic-password", "-s", "margin-webhook-secret", "-w"]
|
|
14
|
+
poll_seconds = 600
|
|
15
|
+
|
|
16
|
+
[batch]
|
|
17
|
+
quiet_seconds = 180
|
|
18
|
+
cap_seconds = 900
|
|
19
|
+
|
|
20
|
+
[notify]
|
|
21
|
+
command = ["my-notifier", "{route}"] # gets the batch as JSON on stdin. {text} is the
|
|
22
|
+
cwd = "~/somewhere" # rendered message, {repo} and {repo_path} name the
|
|
23
|
+
# repo of the batch. cwd is optional.
|
|
24
|
+
author_command = ["who-am-i"] # optional: prints the route of whoever runs
|
|
25
|
+
# `margin link`; their documents' comments go there
|
|
26
|
+
|
|
27
|
+
[[repo]]
|
|
28
|
+
name = "my-project" # becomes the Outline collection
|
|
29
|
+
path = "~/code/my-project"
|
|
30
|
+
include = ["**/*.md"]
|
|
31
|
+
exclude = ["**/node_modules/**"]
|
|
32
|
+
route = "my-project" # passed to the notifier as {route}
|
|
33
|
+
routes = { "docs/design/" = "design" } # optional: by path prefix, longest wins
|
|
34
|
+
# A document no route names goes to whoever last
|
|
35
|
+
# linked it (author_command), else to `route`.
|
|
36
|
+
|
|
37
|
+
Secrets are never written in the file. Each one is the output of a command.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import os
|
|
42
|
+
import subprocess
|
|
43
|
+
import tomllib
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
DEFAULT_PATH = Path(os.environ.get("MARGIN_CONFIG", "~/.config/margin/config.toml")).expanduser()
|
|
48
|
+
DEFAULT_STATE = Path(os.environ.get("MARGIN_STATE", "~/.local/state/margin/margin.db")).expanduser()
|
|
49
|
+
|
|
50
|
+
ALWAYS_EXCLUDE = ["**/.git/**", "**/node_modules/**", "**/.venv/**", "**/venv/**",
|
|
51
|
+
"**/__pycache__/**", "**/.playwright-mcp/**", "**/dist/**", "**/build/**",
|
|
52
|
+
"**/.pytest_cache/**", "**/.mypy_cache/**", "**/.ruff_cache/**", "**/.tox/**",
|
|
53
|
+
"**/*.egg-info/**", "**/.eggs/**", "**/.history/**", "**/site-packages/**"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Repo:
|
|
58
|
+
name: str
|
|
59
|
+
path: Path
|
|
60
|
+
include: list[str] = field(default_factory=lambda: ["**/*.md"])
|
|
61
|
+
exclude: list[str] = field(default_factory=list)
|
|
62
|
+
route: str = ""
|
|
63
|
+
routes: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def route_for(self, path: str) -> str:
|
|
66
|
+
"""The route for a repo-relative path: the longest matching prefix, else the repo's."""
|
|
67
|
+
return self.named_route(path) or self.route
|
|
68
|
+
|
|
69
|
+
def named_route(self, path: str) -> str | None:
|
|
70
|
+
"""The route `routes` names for a path (the longest matching prefix), or None."""
|
|
71
|
+
best = ""
|
|
72
|
+
for prefix in self.routes:
|
|
73
|
+
if (path == prefix or path.startswith(prefix.rstrip("/") + "/")) and len(prefix) > len(best):
|
|
74
|
+
best = prefix
|
|
75
|
+
return self.routes[best] if best else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Config:
|
|
80
|
+
url: str
|
|
81
|
+
api_key_command: list[str]
|
|
82
|
+
admin_key_command: list[str] | None
|
|
83
|
+
pin_ip_command: list[str] | None
|
|
84
|
+
listen: str
|
|
85
|
+
webhook_url: str
|
|
86
|
+
secret_command: list[str] | None
|
|
87
|
+
poll_seconds: int
|
|
88
|
+
quiet_seconds: int
|
|
89
|
+
cap_seconds: int
|
|
90
|
+
notify_command: list[str] | None
|
|
91
|
+
notify_cwd: Path | None
|
|
92
|
+
mcp_url: str | None
|
|
93
|
+
author_command: list[str] | None
|
|
94
|
+
repos: list[Repo]
|
|
95
|
+
state: Path
|
|
96
|
+
|
|
97
|
+
def run(self, argv: list[str]) -> str:
|
|
98
|
+
return subprocess.check_output(argv, text=True).strip()
|
|
99
|
+
|
|
100
|
+
def api_key(self) -> str:
|
|
101
|
+
return self.run(self.api_key_command)
|
|
102
|
+
|
|
103
|
+
def secret(self) -> str | None:
|
|
104
|
+
return self.run(self.secret_command) if self.secret_command else None
|
|
105
|
+
|
|
106
|
+
def author(self) -> str | None:
|
|
107
|
+
"""The route of whoever runs margin now (author_command), or None when there is no such
|
|
108
|
+
command or it does not answer. Never raises: publishing does not depend on it."""
|
|
109
|
+
if not self.author_command:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
out = subprocess.run(self.author_command, capture_output=True, text=True, timeout=20,
|
|
113
|
+
cwd=str(self.notify_cwd) if self.notify_cwd else None)
|
|
114
|
+
except (OSError, subprocess.SubprocessError):
|
|
115
|
+
return None
|
|
116
|
+
name = (out.stdout or "").strip().splitlines()
|
|
117
|
+
return name[0].strip() if out.returncode == 0 and name and name[0].strip() else None
|
|
118
|
+
|
|
119
|
+
def repo(self, name: str) -> Repo:
|
|
120
|
+
for r in self.repos:
|
|
121
|
+
if r.name == name:
|
|
122
|
+
return r
|
|
123
|
+
raise KeyError(f"no repo named {name!r} in the config")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load(path: Path = DEFAULT_PATH) -> Config:
|
|
127
|
+
raw = tomllib.loads(path.read_text())
|
|
128
|
+
o, i, b, n = (raw.get(k, {}) for k in ("outline", "intake", "batch", "notify"))
|
|
129
|
+
repos = [Repo(name=r["name"], path=Path(r["path"]).expanduser(),
|
|
130
|
+
include=r.get("include", ["**/*.md"]), exclude=r.get("exclude", []),
|
|
131
|
+
route=r.get("route", r["name"]), routes=r.get("routes", {}))
|
|
132
|
+
for r in raw.get("repo", [])]
|
|
133
|
+
names = [r.name for r in repos]
|
|
134
|
+
if len(names) != len(set(names)):
|
|
135
|
+
raise ValueError("two [[repo]] entries share a name")
|
|
136
|
+
return Config(
|
|
137
|
+
url=o["url"], api_key_command=o["api_key_command"],
|
|
138
|
+
admin_key_command=o.get("admin_key_command"), pin_ip_command=o.get("pin_ip_command"),
|
|
139
|
+
listen=i.get("listen", "127.0.0.1:8771"), webhook_url=i.get("webhook_url", ""),
|
|
140
|
+
secret_command=i.get("secret_command"), poll_seconds=int(i.get("poll_seconds", 600)),
|
|
141
|
+
quiet_seconds=int(b.get("quiet_seconds", 180)), cap_seconds=int(b.get("cap_seconds", 900)),
|
|
142
|
+
notify_command=n.get("command"),
|
|
143
|
+
notify_cwd=Path(n["cwd"]).expanduser() if n.get("cwd") else None,
|
|
144
|
+
mcp_url=o.get("mcp_url"), repos=repos, author_command=n.get("author_command"),
|
|
145
|
+
state=Path(raw.get("state", DEFAULT_STATE)).expanduser())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def locate(cfg: Config, file: Path) -> tuple[Repo, str, Path] | None:
|
|
149
|
+
"""repo_of, and also a file in another git worktree of a configured repo: (repo, the file's
|
|
150
|
+
repo-relative path, the folder that path is relative to). Every chat works in its own worktree,
|
|
151
|
+
so a file there is found through git's common directory, which names the main checkout."""
|
|
152
|
+
hit = repo_of(cfg, file)
|
|
153
|
+
if hit:
|
|
154
|
+
return hit[0], hit[1], hit[0].path.resolve()
|
|
155
|
+
import subprocess
|
|
156
|
+
f = file.expanduser().resolve()
|
|
157
|
+
try:
|
|
158
|
+
r = subprocess.run(["git", "-C", str(f.parent), "rev-parse", "--path-format=absolute",
|
|
159
|
+
"--show-toplevel", "--git-common-dir"],
|
|
160
|
+
capture_output=True, text=True, timeout=10)
|
|
161
|
+
except (OSError, subprocess.SubprocessError):
|
|
162
|
+
return None
|
|
163
|
+
lines = r.stdout.splitlines()
|
|
164
|
+
if r.returncode != 0 or len(lines) < 2:
|
|
165
|
+
return None
|
|
166
|
+
top, main = Path(lines[0]).resolve(), Path(lines[1]).resolve().parent
|
|
167
|
+
best = None
|
|
168
|
+
for repo in cfg.repos:
|
|
169
|
+
root = repo.path.resolve()
|
|
170
|
+
if root != main and main not in root.parents:
|
|
171
|
+
continue # not this git repository
|
|
172
|
+
here = top / root.relative_to(main) # the configured folder, inside the worktree
|
|
173
|
+
if f == here or here in f.parents:
|
|
174
|
+
if best is None or len(here.parts) > len(best[2].parts):
|
|
175
|
+
best = (repo, f.relative_to(here).as_posix(), here)
|
|
176
|
+
return best
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def repo_of(cfg: Config, file: Path) -> tuple[Repo, str] | None:
|
|
180
|
+
"""The configured repo a file lives in, and its repo-relative path. The deepest repo wins."""
|
|
181
|
+
f = file.expanduser().resolve()
|
|
182
|
+
best = None
|
|
183
|
+
for r in cfg.repos:
|
|
184
|
+
root = r.path.resolve()
|
|
185
|
+
if f == root or root in f.parents:
|
|
186
|
+
if best is None or len(root.parts) > len(best[0].path.resolve().parts):
|
|
187
|
+
best = (r, f.relative_to(root).as_posix())
|
|
188
|
+
return best
|